Skip to content

Linux daemon: support everything except -l/-L and -c/-C - #6

Merged
Will-Howard merged 19 commits into
linux-supportfrom
linux-daemon
Jul 29, 2026
Merged

Linux daemon: support everything except -l/-L and -c/-C#6
Will-Howard merged 19 commits into
linux-supportfrom
linux-daemon

Conversation

@Will-Howard

@Will-Howard Will-Howard commented Jul 28, 2026

Copy link
Copy Markdown
Owner

Adds a linux version of the command line application, aiming to be like-for-like with the mac version. Key points for review:

  • I went with fewer files with more responsibility each relative to the mac version
  • Autostart == add to systemd (whereas on mac it means adding as a login item)
  • The mac version uses sqlite to store app state (e.g. per-repo errors). That would introduce a big dependency in the linux version, so I went with a single json file. I'm planning to bring the mac version in line with this too (macOS: Use json rather than SQLite for state #9)

The engine is the same gitwatch cycle the macOS side runs, so the two
implementations leave a repo in the same state: stage, commit with the
%d date spliced into the first token only, then pull --rebase (-R) and
push (-r/-b), fire-and-forget as upstream. A differential parity suite
runs each scenario through the vendored gitwatch.sh and through us on
identically-built worlds and compares the git state afterwards; those
tests are the spec.

The watcher is recursive inotify with gitwatch's event set, a -s
debounce, .git-churn filtering so our own commits don't retrigger, and
-x exclusion. New subdirectories start being watched as they appear,
and inotify watch exhaustion surfaces as repo state naming the sysctl
to raise rather than killing the daemon.

The daemon is one flock-guarded process that live-reloads ~/.gitwatchd,
publishes per-repo error state to a JSON file for `status`, and retries
a failed push on a 30s-to-5m backoff. The retry only ever re-runs the
push stage of a cycle, never the commit, so the observability layer
stays additive.

The CLI mirrors the mac command set (add/rm/pause/resume/status/start/
stop/config/autostart/doctor) and takes gitwatch's flags verbatim.
Autostart is a systemd user unit; where systemd is absent it says so
and points at `gitwatchd start`, and never edits a shell profile.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
install.sh puts the host-built binary on PATH for the current user.
`make install` and `make uninstall` route through it, so the root
Makefile's forwarding works on Linux the way it already does on macOS.

The build targets keep the shape the skeleton set: all/build/test/
clean, static to build/gitwatchd, test = vet + test.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Will-Howard and others added 17 commits July 28, 2026 16:42
The Go side had grown to ten source files and nine test files for what
is really three concerns, so the package now says so directly: cli.go
is everything the foreground command does (dispatch, the ~/.gitwatchd
config file, repo-spec parsing, status formatting, systemd autostart),
daemon.go is everything the background process does (the daemon loop
and pidfile lock, the recursive inotify watcher, the JSON state file),
and git_driver.go is the git subprocess layer and the gitwatch commit
and push cycle. formattedDate moves in with the driver: it spawns
date(1), so it belongs beside the other subprocess calls rather than
with the flag parser. The tests follow the same three-way split.

Nothing moved between concerns and no function, type or test changed:
this is placement only, so the parity suite is byte-identical and the
same 92 tests run before and after.

The comment pass that comes with it keeps every note that encodes
upstream gitwatch behaviour, the macOS parity constraints and the
non-obvious invariants, and drops the narration and the restatements
of the next line.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The help text now sits at the top of cli.go, where a reader meets the
command surface before the code that implements it, and the config
template is one raw string again: it described 'gitwatchd help' by
concatenating quoted backticks into a raw literal, which was harder to
read than the file it writes.

Renames and comment work: the config lookup key is nameOrPath rather
than needle, since name-or-path is exactly what the user may pass;
OutcomeKind becomes CommitOutcome, matching the macOS enum of the same
name; autoCommit's parity notes become four numbered steps, keeping
every upstream fact but making the order of the cycle the first thing
you see. The -e case says plainly that the flag is accepted for
gitwatch compatibility and ignored.

truncated, ago and span move to utils.go. They are pure formatting with
no gitwatchd vocabulary in them, and they were the only such helpers in
cli.go.

The spawnsDaemon test seam is gone: GITWATCHD_NO_SPAWN already exists
and does the same job, so the in-process CLI tests set that instead.
One seam is enough. Those tests still need it, because os.Executable()
under go test is the test binary, but that leaves the real spawn path
untested, so TestAddSpawnsTheDaemon drives the built binary with an
isolated HOME and without GITWATCHD_NO_SPAWN: add brings the daemon up,
stop takes it down, and nothing survives the test.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The twelve TestParity cases measure us against the vendored upstream
script rather than against our own expectations, which makes them the
oracle for the prime directive and worth finding at a glance. They now
live in their own file along with the harness only they use: the
repoState fingerprint, the gitwatch.sh runner and its watcher stub, the
twin-world builder and the plain-git scenario builders.

The shared fixtures (testRepo, bareRemote) stay in git_driver_test.go,
where the engine tests also use them. A pure move: no test, helper or
assertion changed, and the same 93 tests run before and after.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The vendored upstream script was under macos/Tests/Reference because
the macOS suite was the only thing measuring itself against it. Both
implementations now do, so the oracle is not a macOS test fixture: it
belongs beside macos/ and linux/, named for what it is.

The two parity suites pick it up from the new path, and its README
names them both.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The shared declarations open the file now: where the daemon's runtime
files live (stateDir and the three paths off it), the inotify event mask
gitwatch uses, RepoStatus, and the state file itself. That file's mutex
was a loose global sitting between three free functions, so it is a
stateFile type with the mutex as its one field and statuses, setStatus
and keepOnly as its methods. The daemon hands daemonState.setStatus to
each watcher and `gitwatchd status` calls daemonState.statuses().

runDaemon held a fifty line reload closure over two maps. The maps and
the logger are a daemon struct, so runDaemon reads as lock, reload,
watch the config, wait for a signal, stop, and reloadConfig is a
top-level method you can read on its own.

inotify parsing existed twice: the watcher decoded events through
syscall.InotifyEvent while the config watcher hand-rolled a second
parser out of inotifyNamesContain and uint32frombytes. Both go through
readInotifyEvents now, which reads one fd until it closes and hands each
event's descriptor, mask and name to a callback; the config watcher
compares that name to the config file's basename. The hand-rolled pair
is gone.

Names: fd is inotifyFD, wds is dirByWatchDescriptor, mu is watchMu,
watchErr is watchFailure, events and flush are fileChanges and
flushRequests, stop and done are stopping and stopped, onState is
onStatus. addWatchTracked is watchDirectory, addRecursive is watchTree,
addWatchError is recordWatchFailure, run is runCycles, publish is
publishStatus, handleEvent is handleInotifyEvent.

drain and watchError were single-caller one-liners and are inlined. The
non-blocking sends that coalesce wakeups were the same four lines in
three places; they are one queueWakeup helper that says why a full
channel is success rather than a reason to block.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
stop waited five seconds for the daemon to go and then handed back a
pkill hint, which leaves the user with a running daemon, a command that
reported failure and homework. It now kills the process outright and
says which it took: "daemon stopped", or "daemon stopped (it ignored the
stop signal, so it was killed)". Only a pidfile naming no process, or a
process that survives SIGKILL, is still an error.

The wait is a waitForDaemonExit helper, shared with the handover in
`autostart on`, which polled for the same thing in the same way.

TestStopKillsADaemonThatIgnoresSIGTERM re-executes the test binary in a
mode that takes the pidfile lock and ignores SIGTERM, then runs the real
stop against it and asserts the child died of SIGKILL. With the old
give-up path in place the test fails on stop's exit code.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The macOS app reconciles launch-at-login on every launch of an installed
copy; the Linux daemon asked the user to run `autostart on` themselves.
Port the same contract to daemon startup: the first start of a binary
that lives where install.sh puts it enables the systemd user unit and
records the wish, a wish already recorded as off is never overridden, and
a unit that went missing (a reinstall) is quietly re-enabled.

The decision is a pure function over the recorded wish, where the binary
lives and whether systemd is here, so the whole table is testable on
hosts without systemd. Without systemd the reconcile degrades as the
explicit command does: no shell profile is ever written, the log points
at `gitwatchd start`, and it says so once rather than on every start.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The autostart wish had a file of its own beside the daemon's error state.
Both now live in state.json, pretty-printed with settings as top-level keys
and repo state under "repos": the shape macOS will adopt when it leaves its
SQLite key-value store, and one a person can read and diff.

The file has two writers now (the daemon publishes repo state, the CLI
records settings), so a read-modify-write takes an exclusive flock for its
duration. The lock is a file of its own: state.json is replaced by rename,
so a lock taken on it would be left holding an unlinked inode while the
next writer locked its replacement.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
One installer shape on both platforms: `make install` now does the work
inline, mac-style. It picks the first writable of /usr/local/bin,
~/.local/bin, ~/bin (DEST still overrides), warns when that dir is off
PATH, stops any running daemon before replacing the binary, and starts
the installed copy, which is what turns autostart on for its first run.

Uninstall no longer trusts PATH: it calls `autostart off` through the
binary at each known absolute path, removes the systemd unit itself,
stops the daemon via its pidfile with a SIGKILL fallback, and reports
only what it actually found.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
After 30 consecutive push failures (about two hours of network outage)
the doubling overflowed time.Duration, the delay went negative, and the
retry timer fired immediately: the daemon hammered git push in a tight
loop for the rest of the outage. Compare in float64 before converting,
so a day-long outage retries calmly every five minutes.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@Will-Howard
Will-Howard marked this pull request as ready for review July 29, 2026 15:45
@Will-Howard
Will-Howard merged commit 5dc0fa2 into linux-support Jul 29, 2026
2 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant